// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Azure.Storage;
using Microsoft.Azure.Storage.Blob;
using Microsoft.Azure.WebJobs.Host.Storage;
using Microsoft.Azure.WebJobs.Script.WebHost;
using Microsoft.Azure.WebJobs.Script.WebHost.Models;
using Microsoft.Azure.WebJobs.Script.Workers;
using Microsoft.Azure.WebJobs.Script.Workers.Rpc;
using Microsoft.Extensions.Azure;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Moq;
using Xunit.Abstractions;

namespace Microsoft.Azure.WebJobs.Script.Tests
{
    public static partial class TestHelpers
    {
#if DEBUG
        public const string BuildConfig = "debug";
#else
        public const string BuildConfig = "release";
#endif

        private const string Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";

        /// <summary>
        /// Helper method to inline an action delegate.
        /// </summary>
        /// <param name="act">The action.</param>
        /// <returns>The provided action.</returns>
        /// <remarks>
        /// This is intended to be used with a fluent assertion.
        /// <c>Act(() => { }).Should().Something();</c>.
        /// </remarks>
        public static Action Act(Action act) => act;

        /// <summary>
        /// Helper method to inline an func delegate.
        /// </summary>
        /// <param name="act">The function.</param>
        /// <returns>The provided function.</returns>
        /// <remarks>
        /// This is intended to be used with a fluent assertion.
        /// <c>Act(() => { }).Should().Something();</c>.
        /// </remarks>
        public static Func<T> Act<T>(Func<T> act) => act;

        public static Task WaitOneAsync(this WaitHandle waitHandle)
        {
            ArgumentNullException.ThrowIfNull(waitHandle);

            var tcs = new TaskCompletionSource<bool>();
            var rwh = ThreadPool.RegisterWaitForSingleObject(waitHandle,
                (state, timedOut) => { tcs.TrySetResult(true); }, null, -1, true);
            var t = tcs.Task.ContinueWith((antecedent) => rwh.Unregister(null));

            return t;
        }

        public static async Task RunWithTimeoutAsync(Func<Task> action, TimeSpan timeout)
        {
            Task timeoutTask = Task.Delay(timeout);
            Task actionTask = action();
            Task completedTask = await Task.WhenAny(actionTask, timeoutTask);

            if (completedTask == timeoutTask)
            {
                throw new Exception($"Task did not complete within timeout interval {timeout}.");
            }
        }

        public static byte[] GenerateKeyBytes()
        {
            using (var aes = Aes.Create())
            {
                aes.GenerateKey();
                return aes.Key;
            }
        }

        public static string GenerateKeyHexString(byte[] key = null)
        {
            return BitConverter.ToString(key ?? GenerateKeyBytes()).Replace("-", string.Empty);
        }

        public static string NewRandomString(int length = 10)
        {
            return new string(
                Enumerable.Repeat('x', length)
                    .Select(c => Chars[Random.Shared.Next(Chars.Length)])
                    .ToArray());
        }

        public static Task Await(Func<bool> condition, int timeout = 30 * 1000, int pollingInterval = 50, bool throwWhenDebugging = false, Func<string> userMessageCallback = null)
        {
            return Await(() => Task.FromResult(condition()), timeout, pollingInterval, throwWhenDebugging, userMessageCallback);
        }

        public static async Task Await(Func<Task<bool>> condition, int timeout = 60 * 1000, int pollingInterval = 2 * 1000, bool throwWhenDebugging = false, Func<string> userMessageCallback = null)
        {
            DateTime start = DateTime.Now;
            while (!await condition())
            {
                await Task.Delay(pollingInterval);

                bool shouldThrow = !Debugger.IsAttached || (Debugger.IsAttached && throwWhenDebugging);
                if (shouldThrow && (DateTime.Now - start).TotalMilliseconds > timeout)
                {
                    string error = "Condition not reached within timeout.";
                    if (userMessageCallback != null)
                    {
                        error += " " + userMessageCallback();
                    }
                    throw new ApplicationException(error);
                }
            }
        }

        public static async Task RetryFailedTest(Func<Task> test, int retries, ITestOutputHelper output = null)
        {
            for (int i = 0; i < retries; i++)
            {
                try
                {
                    await test();
                    break;
                }
                catch (Exception ex)
                {
                    if (i == retries - 1)
                    {
                        throw;
                    }
                    else
                    {
                        output.WriteLine($"Test failed attempt {i + 1} of {retries}. Retrying. Exception message: {ex.ToString()}");
                    }
                }
            }
        }

        public static async Task<string> WaitForBlobAndGetStringAsync(CloudBlockBlob blob, Func<string> userMessageCallback = null)
        {
            await WaitForBlobAsync(blob, userMessageCallback: userMessageCallback);

            string result = await blob.DownloadTextAsync(Encoding.UTF8,
                null, new BlobRequestOptions(), new OperationContext());

            return result;
        }

        public static async Task WaitForBlobAsync(CloudBlockBlob blob, Func<string> userMessageCallback = null)
        {
            StringBuilder sb = new();

            await TestHelpers.Await(
                async () =>
                {
                    bool exists = await blob.ExistsAsync();
                    sb.AppendLine($"{blob.Name} exists: {exists}.");
                    return exists;
                },
                pollingInterval: 500,
                userMessageCallback: () =>
                {
                    if (userMessageCallback != null)
                    {
                        sb.AppendLine().Append(userMessageCallback());
                    }
                    return sb.ToString();
                });
        }

        public static void ClearFunctionLogs(string functionName)
        {
            DirectoryInfo directory = GetFunctionLogFileDirectory(functionName);
            if (directory.Exists)
            {
                foreach (var file in directory.GetFiles())
                {
                    file.Delete();
                }
            }
        }

        /// <summary>
        /// Waits until a request sent via the specified HttpClient returns OK or NoContent, indicating
        /// that the host is ready to invoke functions.
        /// </summary>
        /// <param name="client">The HttpClient.</param>
        public static void WaitForWebHost(HttpClient client)
        {
            TestHelpers.Await(() =>
            {
                return IsHostRunning(client);
            }).Wait();
        }

        private static bool IsHostRunning(HttpClient client)
        {
            using HttpRequestMessage request = new(HttpMethod.Get, string.Empty);
            using HttpResponseMessage response = client.SendAsync(request).GetAwaiter().GetResult();
            return response.StatusCode == HttpStatusCode.NoContent || response.StatusCode == HttpStatusCode.OK;
        }

        public static void ClearHostLogs()
        {
            DirectoryInfo directory = GetHostLogFileDirectory();
            if (directory.Exists)
            {
                foreach (var file in directory.GetFiles())
                {
                    try
                    {
                        file.Delete();
                    }
                    catch
                    {
                        // best effort
                    }
                }
            }
        }

        public static IConfiguration GetTestConfiguration()
        {
            return new ConfigurationBuilder()
                    .AddEnvironmentVariables()
                    .AddTestSettings()
                    .Build();
        }

        // Deleting and recreating a container can result in a 409 as the container name is not
        // immediately available. Instead, use this helper to clear a container.
        public static async Task ClearContainerAsync(CloudBlobContainer container)
        {
            foreach (var blob in await ListBlobsAsync(container))
            {
                await blob.DeleteIfExistsAsync();
            }
        }

        public static async Task<IEnumerable<CloudBlockBlob>> ListBlobsAsync(CloudBlobContainer container)
        {
            List<CloudBlockBlob> blobs = new List<CloudBlockBlob>();
            BlobContinuationToken token = null;

            do
            {
                BlobResultSegment blobSegment = await container.ListBlobsSegmentedAsync(token);
                token = blobSegment.ContinuationToken;
                blobs.AddRange(blobSegment.Results.Cast<CloudBlockBlob>());
            }
            while (token != null);

            return blobs;
        }

        public static DirectoryInfo GetFunctionLogFileDirectory(string functionName)
        {
            string path = Path.Combine(Path.GetTempPath(), "Functions", "Function", functionName);
            return new DirectoryInfo(path);
        }

        public static DirectoryInfo GetHostLogFileDirectory()
        {
            string path = Path.Combine(Path.GetTempPath(), "Functions", "Host");
            return new DirectoryInfo(path);
        }

        private static async Task<string[]> ReadAllLinesSafeAsync(string logFile)
        {
            // ReadAllLines won't work if the file is being written to.
            // So try a few more times.

            int count = 0;
            bool success = false;
            string[] logs = null;

            while (!success && count++ < 3)
            {
                try
                {
                    logs = File.ReadAllLines(logFile);
                    success = true;
                }
                catch (IOException)
                {
                    await Task.Delay(500);
                }
            }

            return logs;
        }

        public static async Task<string> ReadStreamToEnd(Stream stream)
        {
            stream.Position = 0;
            using var sr = new StreamReader(stream);
            return await sr.ReadToEndAsync();
        }

        public static IList<RpcWorkerConfig> GetTestWorkerConfigs(
            bool includeDllWorker = false,
            int processCountValue = 1,
            TimeSpan? processStartupInterval = null,
            TimeSpan? processRestartInterval = null,
            TimeSpan? processShutdownTimeout = null,
            bool workerIndexing = false)
        {
            var defaultCountOptions = new WorkerProcessCountOptions();
            TimeSpan startupInterval = processStartupInterval ?? defaultCountOptions.ProcessStartupInterval;
            TimeSpan restartInterval = processRestartInterval ?? defaultCountOptions.ProcessRestartInterval;
            TimeSpan shutdownTimeout = processShutdownTimeout ?? defaultCountOptions.ProcessShutdownTimeout;

            List<RpcWorkerConfig> workerConfigs =
            [
                new()
                {
                    Description = GetTestWorkerDescription("node", ".js", workerIndexing),
                    CountOptions = new WorkerProcessCountOptions
                    {
                        ProcessCount = processCountValue,
                        ProcessStartupInterval = startupInterval,
                        ProcessRestartInterval = restartInterval,
                        ProcessShutdownTimeout = shutdownTimeout
                    }
                },
                new()
                {
                    Description = GetTestWorkerDescription("java", ".jar", workerIndexing),
                    CountOptions = new WorkerProcessCountOptions
                    {
                        ProcessCount = processCountValue,
                        ProcessStartupInterval = startupInterval,
                        ProcessRestartInterval = restartInterval,
                        ProcessShutdownTimeout = shutdownTimeout
                    }
                }
            ];

            // Allow tests to have a worker that claims the .dll extension.
            if (includeDllWorker)
            {
                workerConfigs.Add(new RpcWorkerConfig() { Description = GetTestWorkerDescription("dllWorker", ".dll", workerIndexing) });
            }

            return workerConfigs;
        }

        public static LanguageWorkerOptions GetTestLanguageWorkerOptions()
        {
            return new LanguageWorkerOptions
            {
                WorkerConfigs = GetTestWorkerConfigs()
            };
        }

        public static IList<RpcWorkerConfig> GetTestWorkerConfigsNoLanguage()
        {
            var workerDesc = new RpcWorkerDescription();

            return new List<RpcWorkerConfig>()
            {
                new RpcWorkerConfig() { Description = workerDesc }
            };
        }

        public static IList<RpcWorkerConfig> GetTestWorkerConfigsWithExecutableWorkingDirectory()
        {
            return new List<RpcWorkerConfig>()
            {
                new RpcWorkerConfig
                {
                    Description = new RpcWorkerDescription
                    {
                        Extensions = new List<string>()
                        {
                            { ".jar" }
                        },
                        Language = "java",
                        WorkerDirectory = "testDir",
                        ExecutableWorkingDirectory = "executableDirectory"
                    }
                },
            };
        }

        public static string CreateOfflineFile()
        {
            // create a test offline file
            var offlineFilePath = Path.Combine(Path.GetTempPath(), ScriptConstants.AppOfflineFileName);
            string content = FileUtility.ReadResourceString($"{ScriptConstants.ResourcePath}.{ScriptConstants.AppOfflineFileName}", typeof(HttpException).Assembly);
            File.WriteAllText(offlineFilePath, content);
            return offlineFilePath;
        }

        public static void DeleteTestFile(string testFile)
        {
            if (File.Exists(testFile))
            {
                try
                {
                    File.Delete(testFile);
                }
                catch
                {
                    // best effort cleanup
                }
            }
        }

        public static RpcWorkerDescription GetTestWorkerDescription(string language, string extension, bool workerIndexing = false)
        {
            return new RpcWorkerDescription()
            {
                Extensions = new List<string>()
                 {
                     { extension }
                 },
                Language = language,
                WorkerDirectory = "testDir",
                WorkerIndexing = workerIndexing.ToString()
            };
        }

        public static IOptionsMonitor<T> CreateOptionsMonitor<T>() where T : class, new()
        {
            return CreateOptionsMonitor<T>(new T());
        }

        public static IOptionsMonitor<T> CreateOptionsMonitor<T>(T options) where T : class, new()
        {
            var factory = new TestOptionsFactory<T>(options);
            return new OptionsMonitor<T>(factory, Array.Empty<IOptionsChangeTokenSource<T>>(), factory);
        }

        public static async Task CreateContentZip(string contentRoot, string zipPath, params string[] copyDirs)
        {
            var contentTemp = Path.Combine(contentRoot, @"ZipContent");
            await FileUtility.DeleteDirectoryAsync(contentTemp, true);

            foreach (var sourceDir in copyDirs)
            {
                var directoryName = Path.GetFileName(sourceDir);
                var targetPath = Path.Combine(contentTemp, directoryName);
                FileUtility.EnsureDirectoryExists(targetPath);
                var sourcePath = Path.Combine(Directory.GetCurrentDirectory(), sourceDir);
                FileUtility.CopyDirectory(sourcePath, targetPath);
            }

            FileUtility.DeleteFileSafe(zipPath);
            ZipFile.CreateFromDirectory(contentTemp, zipPath);
        }

        public static async Task<Uri> CreateBlobSas(string connectionString, string filePath, string blobContainer, string blobName)
        {
            CloudStorageAccount storageAccount = CloudStorageAccount.Parse(connectionString);
            var blobClient = storageAccount.CreateCloudBlobClient();
            var container = blobClient.GetContainerReference(blobContainer);
            await container.CreateIfNotExistsAsync();
            var blob = container.GetBlockBlobReference(blobName);
            if (!string.IsNullOrEmpty(filePath))
            {
                await blob.UploadFromFileAsync(filePath);
            }
            var policy = new SharedAccessBlobPolicy
            {
                SharedAccessStartTime = DateTime.UtcNow,
                SharedAccessExpiryTime = DateTime.UtcNow.AddHours(1),
                Permissions = SharedAccessBlobPermissions.Read | SharedAccessBlobPermissions.List
            };
            var sas = blob.GetSharedAccessSignature(policy);
            var sasUri = new Uri(blob.Uri, sas);

            return sasUri;
        }

        public static async Task<Uri> CreateBlobContainerSas(string connectionString, string blobContainer)
        {
            CloudStorageAccount storageAccount = CloudStorageAccount.Parse(connectionString);
            var blobClient = storageAccount.CreateCloudBlobClient();
            var container = blobClient.GetContainerReference(blobContainer);
            await container.CreateIfNotExistsAsync();

            var policy = new SharedAccessBlobPolicy
            {
                SharedAccessStartTime = DateTime.UtcNow,
                SharedAccessExpiryTime = DateTime.UtcNow.AddHours(1),
                Permissions = SharedAccessBlobPermissions.Read | SharedAccessBlobPermissions.Write | SharedAccessBlobPermissions.List | SharedAccessBlobPermissions.Delete
            };
            var sas = container.GetSharedAccessSignature(policy);

            return new Uri(container.StorageUri.PrimaryUri, sas);
        }

        // Creates an IAzureBlobStorageProvider without reacting to Specialization and ActiveHost change. To test the specialization logic, please refer to
        // Microsoft.Azure.WebJobs.Script.Tests.SpecializationE2ETests.
        public static IAzureBlobStorageProvider GetAzureBlobStorageProvider(IConfiguration webHostConfiguration, IConfiguration jobHostConfiguration = null, JobHostInternalStorageOptions storageOptions = null, IScriptHostManager scriptHostManager = null)
        {
            IHost tempHost = new HostBuilder()
                .ConfigureServices(services =>
                {
                    AddTestAzureBlobStorageProvider(services, jobHostConfiguration ?? webHostConfiguration, scriptHostManager: scriptHostManager);
                })
                .ConfigureAppConfiguration(c =>
                {
                    c.AddConfiguration(webHostConfiguration);

                    // These are used internally inside Hosting.
                    c.AddInMemoryCollection(new Dictionary<string, string>
                    {
                        ["shutdownTimeoutSeconds"] = "1",
                        ["startupTimeoutSeconds"] = "1",
                        ["servicesStartConcurrently"] = true.ToString(),
                        ["servicesStopConcurrently"] = true.ToString(),
                    });
                })
                .Build();

            var azureBlobStorageProvider = tempHost.Services.GetRequiredService<IAzureBlobStorageProvider>();
            return azureBlobStorageProvider;
        }

        public static IServiceCollection AddTestAzureBlobStorageProvider(IServiceCollection services, IConfiguration configuration, JobHostInternalStorageOptions storageOptions = null, IScriptHostManager scriptHostManager = null)
        {
            // Adds necessary Azure services to create clients
            services.AddAzureClientsCore();

            if (scriptHostManager == null)
            {
                scriptHostManager = new TestScriptHostService(configuration);
            }

            services.AddSingleton<IScriptHostManager>(scriptHostManager);
            if (storageOptions != null)
            {
                services.AddTransient<IOptions<JobHostInternalStorageOptions>>(s => new OptionsWrapper<JobHostInternalStorageOptions>(storageOptions));
            }

            services.AddSingleton<IAzureBlobStorageProvider, HostAzureBlobStorageProvider>();

            return services;
        }

        public static IAzureTableStorageProvider GetAzureTableStorageProvider(IConfiguration webHostConfiguration, IConfiguration jobHostConfiguration = null, IEnvironment environment = default)
        {
            environment ??= new TestEnvironment();

            IHost tempHost = new HostBuilder()
                .ConfigureServices(services =>
                {
                    AddTestAzureTableStorageProvider(services, jobHostConfiguration ?? webHostConfiguration, environment);
                })
                .ConfigureAppConfiguration(c =>
                {
                    c.AddConfiguration(webHostConfiguration);
                })
                .Build();

            var azureTableStorageProvider = tempHost.Services.GetRequiredService<IAzureTableStorageProvider>();
            return azureTableStorageProvider;
        }

        public static IServiceCollection AddTestAzureTableStorageProvider(IServiceCollection services, IConfiguration configuration, IEnvironment environment, IScriptHostManager scriptHostManager = null)
        {
            // Adds necessary Azure services to create clients
            services.AddAzureClientsCore();

            if (scriptHostManager == null)
            {
                scriptHostManager = new TestScriptHostService(configuration);
            }

            services.AddSingleton<IScriptHostManager>(scriptHostManager);

            services.AddSingleton<IAzureTableStorageProvider, HostAzureTableStorageProvider>();

            return services;
        }

        /// <summary>
        /// Mock an HttpClientFactory and its CreateClient functionality.
        /// </summary>
        /// <param name="handler">Some tests pass a mock HttpHandler into their HttpClient.</param>
        /// <returns>IHttpClientFactory.</returns>
        public static IHttpClientFactory CreateHttpClientFactory(HttpMessageHandler handler = null)
        {
            var httpClient = handler == null ? new HttpClient() : new HttpClient(handler);
            var mockFactory = new Mock<IHttpClientFactory>();
            mockFactory.Setup(m => m.CreateClient(It.IsAny<string>()))
                 .Returns(httpClient);
            return mockFactory.Object;
        }

        public static ManagedServiceIdentity CreateMsi(ManagedServiceIdentityType type, string prefix)
        {
            return new ManagedServiceIdentity
            {
                Type = type,
                ClientId = $"{prefix}-clientId-placeholder",
                PrincipalId = $"{prefix}-principalId-placeholder",
                TenantId = $"{prefix}-tenantId-placeholder",
                Thumbprint = $"{prefix}-thumbprint-placeholder",
                SecretUrl = $"{prefix}-secretUrl-placeholder",
                ResourceId = $"{prefix}-resourceId-placeholder",
                Certificate = $"{prefix}-certificate-placeholder",
                AuthenticationEndpoint = $"{prefix}-authenticationEndpoint-placeholder",
            };
        }

        /// <summary>
        /// Test class for IScriptHostManager to register an IAzureBlobStorageProvider.
        /// </summary>
        public class TestScriptHostService : IScriptHostManager, IServiceProvider
        {
            private readonly IConfiguration _configuration;

            public TestScriptHostService(IConfiguration configuration)
            {
                _configuration = configuration;
            }

            event EventHandler IScriptHostManager.HostInitializing
            {
                add
                {
                    throw new NotImplementedException();
                }

                remove
                {
                    throw new NotImplementedException();
                }
            }

            public event EventHandler<ActiveHostChangedEventArgs> ActiveHostChanged;

            ScriptHostState IScriptHostManager.State => throw new NotImplementedException();

            Exception IScriptHostManager.LastError => throw new NotImplementedException();

            IServiceProvider IScriptHostManager.Services => this;

            public void OnActiveHostChanged()
            {
                ActiveHostChanged?.Invoke(this, new ActiveHostChangedEventArgs(null, null));
            }

            object IServiceProvider.GetService(Type serviceType)
            {
                if (serviceType == typeof(IConfiguration))
                {
                    return _configuration;
                }

                throw new NotImplementedException();
            }

            Task IScriptHostManager.RestartHostAsync(string reason, CancellationToken cancellationToken)
            {
                throw new NotImplementedException();
            }
        }
    }
}
